에이전트 재시도와 멱등성

에이전트 재시도와 멱등성

한눈에 보기

에이전트는 실패를 보고 계획을 다시 세우기 때문에 일반 API client보다 재시도 경로가 많다. timeout은 작업이 실패했다는 뜻이 아니라 결과를 모른다는 뜻일 수 있다. 같은 논리적 작업에는 같은 idempotency key를 사용하고, 서로 다른 시도에는 attempt ID를 부여하며, 실행 결과를 durable ledger에 저장해야 한다. 재시도는 오류가 일시적이고 작업이 멱등할 때만 제한된 budget 안에서 수행한다.

목차

Timeout은 실패가 아니라 모름일 수 있다

에이전트가 고객에게 안내 메일을 보내는 도구를 호출했다고 하자.

sequenceDiagram
    participant A as Agent
    participant T as Mail Tool
    participant M as Mail Provider
    A->>T: 메일 발송 요청
    T->>M: 발송 요청
    M->>M: 메일 생성 완료
    M--xT: 응답 중 연결 끊김
    T-->>A: timeout
    A->>T: 같은 메일 재시도
    T->>M: 두 번째 메일 생성

에이전트에게 보이는 것은 timeout이지만 provider에서는 첫 요청이 성공했을 수 있다. 이때 “오류가 났으니 다시 실행한다”는 판단은 같은 메일을 두 번 보낸다.

클라이언트가 아는 상태: 응답을 받지 못함
서버의 실제 상태: 성공했을 수도, 실패했을 수도 있음
올바른 표현: 결과가 UNKNOWN

네트워크 호출에는 최소 세 시점이 있다.

  1. 요청이 서버에 도착하기 전에 실패한다.
  2. 서버가 처리 중 실패한다.
  3. 서버는 완료했지만 응답 전달이 실패한다.

클라이언트는 timeout만으로 어느 시점인지 구분할 수 없다. 그래서 재시도의 안전성은 오류 코드만 보고 결정할 수 없고 작업 자체의 멱등성과 결과 확인 수단에 달려 있다.

첫 번째 질문

“다시 보내도 될까?”보다 먼저 “같은 요청임을 서버가 알아볼 수 있는가?”를 묻는다.

에이전트는 여러 층에서 재시도한다

일반 서비스 호출도 여러 retry layer를 가지지만, 에이전트에는 모델의 계획 수정이 하나 더 들어간다.

사용자 재요청
  -> Agent가 tool을 다시 선택
    -> Workflow engine이 step 재실행
      -> Queue가 message redelivery
        -> SDK가 HTTP retry
          -> Proxy가 upstream retry

각 계층이 세 번씩 재시도하면 최악의 호출 수는 단순히 3이 아니다.

3 × 3 × 3 × 3 = 81 attempts

Agent는 첫 호출의 이름을 send_email로, 다음 호출을 notify_customer로 바꿀 수도 있다. 도구 이름은 달라도 실제 side effect가 같다면 중복이 생긴다. 따라서 멱등성은 model turn이나 tool call ID가 아니라 업무상 논리적 작업에 묶어야 한다.

계층 맡을 역할 피해야 할 일
Agent 실패 이유에 따른 계획 수정 무조건 같은 side effect 재생성
Workflow 논리적 step 상태와 budget 관리 재시도마다 새 operation ID 생성
Queue 적어도 한 번 전달 중복 전달이 없다고 가정
SDK 짧은 transient network retry 비멱등 요청을 의미 없이 재시도
Tool server deduplication과 결과 반환 process memory에만 결과 저장

가능하면 재시도 정책을 가장 의미를 잘 아는 한 계층에서 통제하고, 하위 SDK의 자동 retry 설정도 확인한다.

멱등성의 의미를 결과 기준으로 본다

멱등 연산은 같은 요청을 여러 번 적용해도 관찰 가능한 최종 상태가 한 번 적용한 것과 같아야 한다.

f(f(x)) = f(x)

HTTP method 이름만으로 판단하면 부족하다. 예를 들어 특정 값으로 상태를 설정하는 요청은 멱등하게 만들기 쉽다.

PUT /deployments/catalog-api
Content-Type: application/json

{"desiredVersion":"2.4.1"}

같은 desired state를 반복 설정하면 최종 상태는 같다. 반면 증가 명령은 반복할 때마다 결과가 바뀐다.

POST /budgets/team-a/increment
Content-Type: application/json

{"amount":100}

하지만 멱등성 key와 ledger가 있으면 POST도 같은 논리적 요청을 한 번만 적용하게 만들 수 있다.

POST /messages
Idempotency-Key: task-42:send-welcome:v1
Content-Type: application/json

{"recipientId":"customer-17","templateId":"welcome-v3"}

멱등성은 “함수가 두 번 실행되지 않는다”가 아니다. 서버 process는 validation과 조회를 반복할 수 있지만 외부에서 보이는 side effect를 하나로 수렴시키고 같은 결과를 돌려주는 계약이다.

로그는 여러 줄이어도 된다

각 attempt의 진단 로그가 남는 것은 멱등성 위반이 아니다. 고객에게 메일 두 통, 결제 두 건, 배포 두 개처럼 업무 결과가 중복되는지가 기준이다.

항상 조건부 비멱등 작업을 분류한다

도구를 등록할 때 retry 특성을 metadata로 선언하면 Agent와 workflow가 일관된 결정을 내릴 수 있다.

분류 예시 자동 재시도 조건
항상 멱등 ID로 조회, desired state 설정 transient error이면 가능
조건부 멱등 version 조건부 수정, key가 있는 생성 precondition 또는 key가 있을 때
비멱등 임의 ID 생성, 이메일 발송, 금액 증가 자동 재시도 금지 또는 reconciliation
type RetrySemantics =
  | { kind: "ALWAYS_IDEMPOTENT" }
  | { kind: "CONDITIONAL"; requires: "IDEMPOTENCY_KEY" | "VERSION" }
  | { kind: "NON_IDEMPOTENT"; recovery: "MANUAL" | "RECONCILE" };

type ToolDefinition = {
  name: string;
  retrySemantics: RetrySemantics;
  timeoutMs: number;
  maxAttempts: number;
};

삭제도 무조건 안전하다고 볼 수 없다. DELETE /file/current가 첫 번째에는 version A를 삭제하고, 사이에 누군가 version B를 만들었는데 재시도가 B를 삭제한다면 최종 영향이 달라진다. 대상 resource version이나 ETag를 precondition으로 묶어야 한다.

DELETE /documents/doc-17
If-Match: "revision-8"

Google Cloud Storage의 retry 지침도 요청이 본질적으로 멱등한지, generation이나 ETag 같은 precondition이 있을 때만 조건부로 멱등한지 구분한다.

작업 ID와 시도 ID를 구분한다

모든 retry에 새 UUID를 만들면 서버는 서로 다른 작업으로 인식한다. 반대로 모든 이벤트에 같은 ID만 쓰면 각 시도의 latency와 오류를 구분하기 어렵다.

operationId  = 업무상 한 번만 일어나야 할 논리적 작업
attemptId    = 실제 네트워크 또는 worker 실행 한 번
taskId       = 여러 operation을 포함하는 에이전트 작업
toolCallId   = 한 model response가 생성한 호출
{
  "taskId": "task-42",
  "operationId": "task-42:customer-17:welcome-email:v1",
  "attemptId": "att-03",
  "toolCallId": "call-9a2f",
  "attemptNumber": 3
}

같은 operation의 retry에는 같은 operationId를 사용하고 새 attemptId를 만든다. 사용자가 의도를 바꿔 수신자나 template을 변경했다면 새 operation이다.

flowchart TB
    T[Task task-42] --> O1[Operation send welcome v1]
    T --> O2[Operation create CRM note]
    O1 --> A1[Attempt 1 timeout]
    O1 --> A2[Attempt 2 returns prior result]
    O2 --> A3[Attempt 1 success]

이 관계를 저장하면 “도구가 세 번 호출됐다”와 “메일이 세 번 생성됐다”를 구분할 수 있다.

멱등성 Key에는 작업의 의도를 담는다

key 생성 전략은 크게 client-generated random key와 domain-derived key로 나눌 수 있다.

const randomKey = crypto.randomUUID();

const domainKey = [
  "task-42",
  "send-welcome",
  "customer-17",
  "template-v3",
].join(":");

Random key는 충돌 가능성이 낮지만 retry 시 원래 key를 durable하게 보존해야 한다. Domain-derived key는 의미가 보이지만 개인정보 노출, 문자열 ambiguity와 의도하지 않은 충돌을 조심해야 한다.

실무에서는 안정적인 operation UUID를 먼저 영속 저장하고 그것을 key로 전달하는 방식이 단순하다.

type AgentOperation = {
  id: string;
  taskId: string;
  stepName: string;
  requestHash: string;
  createdAt: string;
};

key는 다음 조건을 만족해야 한다.

Attempt 번호를 key에 넣지 않는다

task-42:send-email:attempt-3은 관찰에는 유용하지만 매번 다른 key이므로 중복 방지 역할을 하지 못한다. attempt ID는 별도 header나 trace attribute로 전달한다.

같은 Key에 다른 인수를 허용하지 않는다

같은 key로 수신자만 바꿔 요청했을 때 기존 성공 결과를 반환하면 새 요청이 조용히 무시된다. 반대로 새 요청으로 처리하면 key의 중복 방지 계약이 깨진다. 따라서 key와 요청 hash를 함께 저장한다.

function canonicalRequestHash(input: SendEmailInput): string {
  const canonical = JSON.stringify({
    recipientId: input.recipientId,
    templateId: input.templateId,
    templateVariables: sortObject(input.templateVariables),
  });
  return sha256(canonical);
}
same key + same request hash
  -> 이전 결과 또는 진행 상태 반환

same key + different request hash
  -> 409 IDEMPOTENCY_KEY_REUSED

어떤 필드를 hash에 포함할지 신중해야 한다. 실행 의미에 영향을 주는 수신자, 금액, resource, version은 포함한다. trace ID나 요청 시각처럼 retry마다 달라지는 metadata는 제외한다.

Stripe 문서도 같은 idempotency key로 들어온 후속 요청의 parameter가 원래 요청과 같은지 비교하고 다르면 오류로 처리하는 방식을 설명한다.

오류를 재시도 가능성으로 분류한다

catch에서 모든 오류를 재시도하면 validation error와 권한 오류까지 반복해 부하만 키운다.

type FailureClass =
  | "TRANSIENT"
  | "THROTTLED"
  | "PERMANENT"
  | "CONFLICT"
  | "UNKNOWN_OUTCOME";

type RetryDecision = {
  failureClass: FailureClass;
  retry: boolean;
  delayMs?: number;
  requiresReconciliation?: boolean;
};
오류 일반적 분류 처리
연결 전 DNS 실패 Transient 멱등 작업이면 재시도
HTTP 429 Throttled Retry-After 존중
HTTP 500·502·503·504 Transient 후보 API 계약과 멱등성 확인
HTTP 400 validation Permanent 입력 수정 전 재시도 금지
HTTP 401·403 Permanent 후보 credential refresh 가능 여부 판단
HTTP 409 version conflict Conflict 새 상태 조회와 재계획
write 뒤 response timeout Unknown outcome 같은 key 조회 또는 reconciliation

HTTP code만으로 완전히 분류할 수 없다. 서비스 계약에서 오류 의미와 side effect 발생 가능성을 확인해야 한다. 500도 첫 실행 결과를 idempotency cache가 재생한 것일 수 있다.

function decideRetry(error: ToolError, operation: AgentOperation): RetryDecision {
  if (error.kind === "VALIDATION" || error.kind === "FORBIDDEN") {
    return { failureClass: "PERMANENT", retry: false };
  }

  if (error.kind === "CONFLICT") {
    return { failureClass: "CONFLICT", retry: false };
  }

  if (error.kind === "TIMEOUT_AFTER_WRITE") {
    return {
      failureClass: "UNKNOWN_OUTCOME",
      retry: operation.hasIdempotencyGuarantee,
      requiresReconciliation: !operation.hasIdempotencyGuarantee,
    };
  }

  return { failureClass: "TRANSIENT", retry: operation.isRetrySafe };
}

Exponential Backoff와 Jitter

일시 장애 때 모든 worker가 즉시 같은 간격으로 재시도하면 복구 중인 서비스를 다시 압박한다. 지수 backoff는 시도할수록 대기 시간을 늘리고 jitter는 동시 요청의 타이밍을 흩뜨린다.

base delay = 500ms
attempt 1  = 최대 500ms 사이 무작위
attempt 2  = 최대 1,000ms 사이 무작위
attempt 3  = 최대 2,000ms 사이 무작위
attempt 4  = 최대 4,000ms 사이 무작위
cap        = 10,000ms
function fullJitterDelay(
  attemptNumber: number,
  baseMs = 500,
  capMs = 10_000,
): number {
  const exponential = Math.min(capMs, baseMs * 2 ** (attemptNumber - 1));
  return Math.floor(Math.random() * exponential);
}

예시 함수를 production에서 직접 복사하기보다 사용하는 SDK의 검증된 retry 기능을 먼저 확인한다. Retry-After가 있다면 server 신호를 우선하며, 전체 deadline을 넘지 않게 delay를 자른다.

const remainingMs = deadline.getTime() - clock.now().getTime();
const delayMs = Math.min(
  error.retryAfterMs ?? fullJitterDelay(attempt),
  remainingMs,
);

Backoff는 중복 side effect를 막지 않는다. 요청 사이를 벌릴 뿐이므로 idempotency와 함께 사용해야 한다.

재시도는 한 계층에서 예산으로 관리한다

횟수 제한만 두면 느린 요청에서 총 시간이 과도하게 길어질 수 있다. attempt 수와 전체 deadline, 비용을 하나의 budget으로 관리한다.

type RetryBudget = {
  maxAttempts: number;
  deadlineAt: Date;
  maxEstimatedCostUsd: number;
  maxToolCalls: number;
};

에이전트에서는 token과 tool 비용도 포함해야 한다. 실패 때마다 모델에게 전체 대화를 다시 보내면 외부 호출은 적어도 비용이 크게 늘어난다.

retry 가능 조건
  = transient error
  AND operation is idempotent
  AND attempt < maxAttempts
  AND now + nextDelay < deadline
  AND cost budget remains
  AND task is not cancelled

하위 SDK가 이미 3회 재시도한다면 workflow가 보는 attempt 한 번에 network 요청은 최대 4번일 수 있다. metrics와 budget에 실제 downstream attempt를 포함시키거나 SDK retry를 끄고 상위 계층에서 통합한다.

가장 의미를 아는 계층에서 재시도한다

단순 연결 오류는 SDK가 잘 처리할 수 있지만, version conflict 뒤 최신 상태를 조회하고 계획을 고치는 일은 workflow나 Agent가 맡아야 한다.

결과를 영속 Ledger에 저장한다

process memory의 Map으로 중복을 막으면 재시작과 여러 instance를 견디지 못한다.

// 단일 프로세스 demo 외에는 부족하다.
const completed = new Map<string, unknown>();

DB에 operation과 결과를 저장한다.

CREATE TABLE idempotency_operations (
  operation_id TEXT PRIMARY KEY,
  request_hash TEXT NOT NULL,
  status TEXT NOT NULL CHECK (status IN (
    'STARTED', 'SUCCEEDED', 'FAILED_FINAL', 'UNKNOWN'
  )),
  owner_token TEXT,
  response_code INTEGER,
  response_body JSONB,
  external_operation_id TEXT,
  lease_expires_at TIMESTAMPTZ,
  created_at TIMESTAMPTZ NOT NULL,
  updated_at TIMESTAMPTZ NOT NULL,
  expires_at TIMESTAMPTZ NOT NULL
);

상태별 응답은 다르게 한다.

저장 상태 같은 key 요청 처리
SUCCEEDED 저장한 성공 결과 반환
FAILED_FINAL 같은 최종 오류 반환
STARTED + 유효 lease 진행 중 응답 또는 대기
STARTED + 만료 lease reconciliation 후 takeover 판단
UNKNOWN 외부 상태 확인, 맹목 재실행 금지
key 없음 새 operation 생성

결과를 얼마나 오래 보존할지도 계약의 일부다. deduplication record를 지운 뒤 오래된 client가 같은 key를 재사용하면 새 작업으로 실행될 수 있다. workflow의 최대 재시도·queue 보존 기간보다 충분히 길게 보존하고, 삭제된 key를 재사용하지 않게 operation ID를 고유하게 만든다.

동시 요청을 원자적으로 합친다

다음처럼 조회 후 insert하면 두 요청이 동시에 key가 없다고 볼 수 있다.

const existing = await operations.find(operationId);
if (!existing) {
  await operations.insert(newOperation);
  await performSideEffect();
}

DB의 unique constraint와 transaction을 이용해 winner를 한 명으로 만든다.

INSERT INTO idempotency_operations (
  operation_id, request_hash, status,
  owner_token, lease_expires_at,
  created_at, updated_at, expires_at
)
VALUES ($1, $2, 'STARTED', $3, now() + interval '30 seconds', now(), now(), $4)
ON CONFLICT (operation_id) DO NOTHING
RETURNING *;

반환된 row가 있으면 이번 요청이 실행 owner다. 없으면 기존 row를 조회해 request hash와 상태를 검사한다.

async function claimOperation(input: ClaimInput): Promise<ClaimResult> {
  const created = await operations.insertIfAbsent(input);
  if (created) return { kind: "OWNER", operation: created };

  const existing = await operations.findRequired(input.operationId);
  if (existing.requestHash !== input.requestHash) {
    return { kind: "KEY_REUSED_WITH_DIFFERENT_INPUT" };
  }
  if (existing.status === "SUCCEEDED") {
    return { kind: "REPLAY", response: existing.responseBody };
  }
  return { kind: "IN_PROGRESS_OR_UNKNOWN", operation: existing };
}

lease가 만료됐다고 바로 side effect를 다시 실행하면 이전 owner가 여전히 동작 중일 수 있다. fencing token이나 외부 idempotency key를 사용하거나 reconciliation으로 실제 결과를 먼저 확인한다.

외부 API가 멱등성을 지원하지 않을 때

내부 ledger가 있어도 external API 호출과 DB 결과 저장을 하나의 transaction으로 묶을 수 없다.

sequenceDiagram
    participant W1 as Worker 1
    participant DB as Ledger
    participant X as External API
    participant W2 as Worker 2
    W1->>DB: STARTED 저장
    W1->>X: 비멱등 작업 실행
    X-->>W1: 성공
    W1--xDB: 결과 저장 전 crash
    W2->>DB: STARTED lease 만료 확인
    Note over W2,X: 다시 실행하면 중복 가능

대응 방법은 API 성격에 따라 다르다.

  1. 외부 provider가 제공하는 idempotency key를 사용한다.
  2. client가 지정한 resource ID로 생성해 동일 ID 충돌을 이용한다.
  3. 자연 key와 unique constraint가 있는 내부 adapter를 둔다.
  4. 외부 operation ID를 얻는 비동기 API를 사용하고 조회한다.
  5. 실행 전후 외부 상태를 조회해 reconciliation한다.
  6. 확인 수단이 없으면 UNKNOWN으로 두고 사람에게 넘긴다.
async function reconcileEmail(operation: Operation) {
  const found = await mailProvider.findByCustomHeader(
    "X-Agent-Operation-Id",
    operation.id,
  );

  if (found.length === 1) return { status: "SUCCEEDED", id: found[0].id };
  if (found.length > 1) return { status: "DUPLICATED", ids: found.map((x) => x.id) };
  return { status: "NOT_FOUND" };
}

Provider가 검색도 custom key도 지원하지 않으면 안전한 자동 재시도를 보장하기 어렵다. 그런 tool은 metadata에 NON_IDEMPOTENT로 표시하고 timeout 뒤 승인을 다시 받거나 운영자가 확인하게 한다.

재구성한 TypeScript 실행기

다음은 멱등성 ledger와 retry policy의 책임을 보여 주는 예시다. 실제 프로젝트 코드를 옮긴 것이 아니다.

type ExecuteInput<T> = {
  operationId: string;
  request: T;
  requestHash: string;
  retrySemantics: RetrySemantics;
  budget: RetryBudget;
};

type AttemptContext = {
  attemptId: string;
  attemptNumber: number;
  operationId: string;
  deadlineAt: Date;
};
async function executeWithRetry<TRequest, TResult>(
  input: ExecuteInput<TRequest>,
  call: (request: TRequest, context: AttemptContext) => Promise<TResult>,
): Promise<TResult> {
  const claim = await ledger.claim({
    operationId: input.operationId,
    requestHash: input.requestHash,
    expiresAt: retentionFor(input.budget),
  });

  if (claim.kind === "REPLAY") return claim.response as TResult;
  if (claim.kind === "KEY_REUSED_WITH_DIFFERENT_INPUT") {
    throw new Error("idempotency key reused with different request");
  }
  if (claim.kind !== "OWNER") {
    return await waitForOrReconcile<TResult>(input.operationId);
  }

  for (let attempt = 1; attempt <= input.budget.maxAttempts; attempt += 1) {
    const context: AttemptContext = {
      attemptId: crypto.randomUUID(),
      attemptNumber: attempt,
      operationId: input.operationId,
      deadlineAt: input.budget.deadlineAt,
    };

    try {
      const result = await call(input.request, context);
      await ledger.markSucceeded(input.operationId, result);
      return result;
    } catch (error) {
      const classified = classifyToolError(error);
      await ledger.recordAttempt(input.operationId, context, classified);

      const decision = retryPolicy.decide({
        error: classified,
        semantics: input.retrySemantics,
        attempt,
        budget: input.budget,
      });

      if (decision.requiresReconciliation) {
        await ledger.markUnknown(input.operationId, classified);
        return await reconcile<TResult>(input.operationId);
      }

      if (!decision.retry) {
        await ledger.markFinalFailure(input.operationId, classified);
        throw error;
      }

      await abortableDelay(decision.delayMs, input.budget.deadlineAt);
    }
  }

  throw new Error("retry budget exhausted");
}

실제 구현에서는 markSucceeded 실패도 고려해야 한다. 외부 호출이 성공한 뒤 ledger update가 실패하면 다시 UNKNOWN이 된다. 외부 provider의 key나 operation ID가 있어야 복구할 수 있다.

또한 model이 operationId를 자유 문자열로 만들게 두지 않는다. Workflow engine이 task의 durable step에서 ID를 발급하고 재계획 뒤에도 같은 업무 의도라면 재사용한다.

const operation = await workflow.getOrCreateOperation({
  taskId,
  stepKey: "notify-customer-after-resolution",
  requestHash,
});

보상 작업은 멱등성의 대체재가 아니다

중복 실행 뒤 취소하면 된다는 생각도 위험하다.

메일 두 번 발송 -> 한 통 취소 불가
결제 두 번 승인 -> 환불 수수료와 알림 발생
계정 두 개 생성 -> 외부 identifier와 audit 흔적 잔존
배포 두 번 시작 -> rollout 상태 경합

Saga의 compensation은 원래 작업과 의미가 정확히 반대인 transaction rollback이 아니다. 별도의 side effect이며 그 자체도 실패하고 재시도될 수 있다.

type Compensation = {
  originalOperationId: string;
  compensationOperationId: string;
  reason: string;
  status: "PENDING" | "RUNNING" | "SUCCEEDED" | "FAILED";
};

먼저 멱등성으로 중복을 막고, 부분 성공을 되돌릴 업무 절차로 compensation을 설계한다. 보상 작업에도 독립된 idempotency key가 필요하다.

Queue와 Worker에서의 재시도

대부분의 queue는 at-least-once delivery를 전제로 설계하는 것이 안전하다. worker가 처리 후 ack 전에 죽으면 같은 message가 다시 올 수 있다.

async function consume(message: AgentOperationMessage) {
  try {
    await executeOperation(message.operationId);
    await queue.ack(message.id);
  } catch (error) {
    const failure = classifyToolError(error);

    if (failure.retryable && message.attempt < MAX_DELIVERIES) {
      await queue.retry(message.id, nextDelay(message.attempt));
      return;
    }

    await queue.moveToDeadLetter(message.id, failure.code);
  }
}

이 코드에서도 executeOperation이 operation ID로 멱등해야 한다. ack 순서를 바꾸면 다른 문제가 생긴다.

순서 장애 시 문제
실행 후 ack ack 전 crash면 중복 전달
ack 후 실행 실행 전 crash면 작업 유실

그래서 at-least-once + idempotent consumer가 흔한 조합이다. DLQ에 보낼 때는 작업을 끝난 것으로 숨기지 말고 workflow 상태를 NEEDS_ATTENTION으로 전이한다.

Poison message에 무한 retry하지 않도록 max delivery, 전체 age와 error fingerprint를 제한한다. 같은 validation error가 반복된다면 backoff가 아니라 입력 수정이 필요하다.

테스트해야 할 실패 시점

재시도 기능은 오류 응답만 mock해서는 충분하지 않다. side effect 전후의 각 crash 지점에 장애를 주입한다.

1. ledger claim 전 실패
2. STARTED 저장 직후 실패
3. 외부 API에 요청 전 연결 실패
4. 외부 API 처리 중 timeout
5. 외부 API 성공 응답 전달 중 실패
6. 성공 결과를 ledger에 저장하기 전 실패
7. ledger 성공 저장 후 response 전 실패
8. queue ack 전 실패

기대하는 불변 조건을 정한다.

- 같은 operation ID와 request hash는 최대 하나의 업무 결과를 만든다.
- 같은 operation ID와 다른 request hash는 실행되지 않는다.
- 모든 attempt는 고유한 attempt ID로 관찰된다.
- retry budget을 넘으면 자동 실행을 멈춘다.
- UNKNOWN 결과는 비멱등 작업을 맹목적으로 재실행하지 않는다.
- 취소된 task는 대기 중 retry를 실행하지 않는다.
it("replays the stored result after response loss", async () => {
  provider.succeedButDropFirstResponse();

  const first = executeOperation(operation);
  await expect(first).rejects.toThrow("connection lost");

  const second = await executeOperation(operation);

  expect(second.messageId).toBe(provider.createdMessages()[0].id);
  expect(provider.createdMessages()).toHaveLength(1);
});

동시성 테스트에서는 같은 key 요청을 여러 개 동시에 보내 실제 DB unique constraint와 lock 동작을 확인한다. 가짜 in-memory repository는 경쟁 조건을 숨길 수 있다.

운영 지표와 알림

재시도는 성공률을 높이기도 하지만 작은 장애를 retry storm으로 증폭하기도 한다.

지표 의미
operations total 논리적 업무 수
attempts per operation 재시도 증폭 정도
first-attempt success rate 의존성 기본 건강도
retry success rate 재시도의 실제 효용
idempotency replay count 흡수한 중복 요청 수
key mismatch count key 재사용 bug 또는 공격
unknown outcome count reconciliation이 필요한 작업
budget exhausted count 자동 복구 실패 규모
retry delay distribution backoff 설정 적합성
downstream calls per operation 숨은 SDK retry 포함 증폭률
DLQ age and count 수동 대응 backlog

로그는 operation과 attempt를 함께 남긴다.

{
  "event": "agent.operation.attempt.failed",
  "taskId": "task-42",
  "operationId": "op-7a91",
  "attemptId": "att-03",
  "attemptNumber": 3,
  "failureClass": "UNKNOWN_OUTCOME",
  "retryDecision": "RECONCILE",
  "elapsedMs": 3021,
  "policyVersion": "retry-policy-5",
  "recordedAt": "2026-05-08T03:21:00Z"
}

Alert는 단순 error count보다 비율과 결과를 본다. attempt 오류가 늘어도 operation 성공률이 유지될 수 있지만 비용과 latency는 악화된다. 반대로 unknown outcome 한 건은 결제나 배포처럼 영향이 큰 tool에서는 즉시 확인 대상일 수 있다.

마무리

에이전트의 재시도는 “오류가 나면 세 번 더 호출한다”로 구현할 수 없다. timeout 뒤에는 성공과 실패가 아니라 결과를 모르는 상태가 있으며, Agent·workflow·queue·SDK·proxy의 retry가 서로 곱해질 수 있다.

같은 업무 의도에는 같은 operation ID를 사용하고, 각 실행 시도에는 별도 attempt ID를 부여하며, 최초 결과를 영속 저장해 이후 요청이 그 결과로 수렴하게 만든다.

실무 적용 기준은 다음과 같다.

  1. tool의 작업을 항상·조건부·비멱등으로 분류한다.
  2. 논리적 operation ID와 물리적 attempt ID를 구분한다.
  3. retry에서 operation ID를 바꾸지 않는다.
  4. 같은 key와 다른 request hash를 명시적으로 거부한다.
  5. timeout을 UNKNOWN_OUTCOME으로 다룰 수 있게 한다.
  6. transient, throttled, permanent와 conflict 오류를 구분한다.
  7. exponential backoff와 jitter, 전체 deadline을 함께 사용한다.
  8. 여러 계층의 retry가 곱해지지 않도록 budget을 통합한다.
  9. 결과와 진행 상태를 durable idempotency ledger에 저장한다.
  10. unique constraint와 transaction으로 동시 owner를 하나로 만든다.
  11. 외부 provider의 idempotency key와 operation 조회 API를 활용한다.
  12. 확인할 수 없는 비멱등 결과는 자동 재실행하지 않는다.
  13. queue redelivery와 worker crash를 정상 시나리오로 테스트한다.
  14. operation 성공률과 실제 downstream 증폭률을 함께 관찰한다.

재시도는 실패를 숨기는 기술이 아니라 불확실한 결과를 같은 논리적 작업으로 수렴시키는 프로토콜이다. 이 프로토콜이 있어야 에이전트가 적극적으로 복구를 시도하면서도 메일, 결제, 배포와 데이터 변경을 중복시키지 않는다.

참고 자료

관련 노트